Skip to content

fix(adapters): escape YAML frontmatter values consistently across all command adapters - #1447

Merged
clay-good merged 8 commits into
Fission-AI:mainfrom
hsusul:fix/command-adapter-yaml-escaping
Jul 28, 2026
Merged

fix(adapters): escape YAML frontmatter values consistently across all command adapters#1447
clay-good merged 8 commits into
Fission-AI:mainfrom
hsusul:fix/command-adapter-yaml-escaping

Conversation

@hsusul

@hsusul hsusul commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Description

Centralize and enforce YAML frontmatter value escaping across all tool command adapters.

Root Cause

Command adapters emitting YAML frontmatter (amazon-q, antigravity, auggie, bob, codebuddy, continue, costrict, crush, factory, github-copilot, iflow, junie, kiro, lingma, opencode, qoder, qwen, trae, zcode) either omitted string escaping or re-implemented local un-escaped/duplicate variants of escapeYamlValue. When command descriptions, names, categories, or tags contained YAML special characters (e.g. colons : , quotes ", newlines \n, carriage returns \r, or brackets []), generated command files contained invalid YAML frontmatter that failed YAML parsers.

Fix Details

  1. Exported escapeYamlValue and formatTagsArray from src/core/command-generation/yaml.ts as the single authoritative helper module.
  2. Updated escapeYamlValue to return "" when given an empty string so empty fields produce valid YAML scalars instead of unquoted trailing whitespace.
  3. Refactored all YAML-emitting command adapters (amazon-q, antigravity, auggie, bob, codebuddy, continue, costrict, crush, factory, github-copilot, iflow, junie, kiro, lingma, opencode, qoder, qwen, trae, windsurf, zcode) to import and use shared escapeYamlValue and formatTagsArray helpers.
  4. Added an exhaustive Vitest matrix in test/core/command-generation/adapters.test.ts verifying that frontmatter emitted across all YAML adapters parses cleanly with yaml.parse.

Verification

  • Vitest Suite: pnpm test (112 test files passed, 2275 tests passed)
  • Build: pnpm run build (Clean build completion)
  • Linter: pnpm run lint (0 ESLint errors)
  • TypeScript: pnpm tsc --noEmit (0 type errors)
  • Git Diff: git diff --check (Clean formatting, no trailing whitespace)

Summary by CodeRabbit

  • Bug Fixes

    • Improved generated command files across supported integrations by escaping YAML frontmatter fields (including name/description/category) so special characters render safely and consistently as quoted YAML strings.
    • Ensured empty descriptions render as "" and tag frontmatter values are emitted in a consistent YAML-safe array format.
  • Tests

    • Expanded YAML round-trip and adapter coverage for escaping/quoting behavior.
    • Updated golden/template parity checks and hashes to match the new frontmatter formatting.
  • Documentation

    • Updated bulk-archive workflow guidance/templates to clarify inline sync, verification ordering, and handling of skipped vs excluded deltas.

@hsusul
hsusul requested a review from TabishB as a code owner July 26, 2026 16:23
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Shared YAML serialization now consistently quotes and escapes frontmatter values, including tag arrays. Command-generation adapters use the shared helpers, with tests covering YAML round trips. Archive workflows now require inline synchronization and main-spec verification before archiving.

YAML frontmatter generation

Layer / File(s) Summary
Shared YAML serialization
src/core/command-generation/yaml.ts
escapeYamlValue now emits escaped quoted scalars, and formatTagsArray serializes escaped tag arrays.
Adapter frontmatter migration
src/core/command-generation/adapters/*
Adapters use shared escaping and tag formatting, replacing duplicated local helpers where applicable.
Frontmatter validation
test/core/command-generation/*, test/commands/artifact-workflow.test.ts, test/core/init.test.ts
Tests verify quoted output and YAML round trips for special descriptions and tags.

Archive workflow synchronization

Layer / File(s) Summary
Archive execution contracts
skills/openspec-bulk-archive-change/SKILL.md, src/core/templates/workflows/*
Archive instructions require inline synchronization, included-delta verification before moving changeRoot, updated conflict paths, and separate sync-skipped outcomes.
Archive template validation
test/core/templates/skill-templates-parity.test.ts
Parity hashes and behavioral assertions cover synchronization, verification sequencing, delta semantics, and archive gating.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: tabishb, alfred-openspec

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: consistent YAML frontmatter escaping across command adapters.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/core/command-generation/adapters.test.ts`:
- Around line 18-21: Update the YAML adapter matrix in the adapter tests to
include kiroAdapter and lingmaAdapter alongside the existing YAML-emitting
adapters. Keep kilocodeAdapter excluded from yamlAdapters because its dedicated
coverage verifies that it does not emit YAML frontmatter.
- Around line 977-997: Update escapeYamlValue, used by the adapter formatFile
flow, to quote values that YAML would interpret as non-string scalars, including
true, null, and numeric values, and to exclude indicator-prefixed text such as
“- starts here” from plain scalars. Extend the existing YAML frontmatter tests
with round-trip cases asserting these values parse back as the original strings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b1b1ec2-de80-4b21-8370-9916276171d4

📥 Commits

Reviewing files that changed from the base of the PR and between 19d4171 and c8fe7fe.

📒 Files selected for processing (23)
  • src/core/command-generation/adapters/amazon-q.ts
  • src/core/command-generation/adapters/antigravity.ts
  • src/core/command-generation/adapters/auggie.ts
  • src/core/command-generation/adapters/bob.ts
  • src/core/command-generation/adapters/claude.ts
  • src/core/command-generation/adapters/codebuddy.ts
  • src/core/command-generation/adapters/continue.ts
  • src/core/command-generation/adapters/costrict.ts
  • src/core/command-generation/adapters/crush.ts
  • src/core/command-generation/adapters/factory.ts
  • src/core/command-generation/adapters/github-copilot.ts
  • src/core/command-generation/adapters/iflow.ts
  • src/core/command-generation/adapters/junie.ts
  • src/core/command-generation/adapters/kiro.ts
  • src/core/command-generation/adapters/lingma.ts
  • src/core/command-generation/adapters/opencode.ts
  • src/core/command-generation/adapters/qoder.ts
  • src/core/command-generation/adapters/qwen.ts
  • src/core/command-generation/adapters/trae.ts
  • src/core/command-generation/adapters/windsurf.ts
  • src/core/command-generation/adapters/zcode.ts
  • src/core/command-generation/yaml.ts
  • test/core/command-generation/adapters.test.ts

Comment thread test/core/command-generation/adapters.test.ts
Comment thread test/core/command-generation/adapters.test.ts Outdated
@clay-good
clay-good requested a review from a team as a code owner July 27, 2026 17:31

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The affected encoder code is unchanged from the reproduced hold: values beginning with a block indicator can produce invalid YAML, while true, null, and numeric-looking strings round-trip as other scalar types. Please serialize every frontmatter string safely and add table-driven round-trip coverage across the changed adapters, including Kiro and Lingma.

@clay-good

Copy link
Copy Markdown
Collaborator

Code review — verdict: good to merge

I was initially wary of this one — 23 files touching 20 adapters is exactly the shape that quietly changes output for every user. So I didn't review the diff; I generated the full artifact tree for all 21 affected tools on origin/main and on this branch and compared them directly. The result strongly supports merging.

1. Real need — yes, and it's bigger than the description suggests

origin/main is currently emitting invalid YAML. Generated frontmatter, before:

---
name: OPSX: Apply
description: "Implement tasks from an OpenSpec change (Experimental)"
argument-hint: "[command arguments]"
---

name: OPSX: Apply is a syntax error — a plain scalar can't contain ": ". Parsing it:

BASE  /.codebuddy/commands/opsx/apply.md   PARSE ERROR: Nested mappings are not allowed in compact mappings at line 1, column 7
BASE  /.crush/commands/opsx/apply.md       PARSE ERROR: Nested mappings are not allowed in compact mappings at line 1, column 7
BASE  /.qoder/commands/opsx/apply.md       PARSE ERROR: Nested mappings are not allowed in compact mappings at line 1, column 7
BASE  /.lingma/commands/opsx/apply.md      PARSE ERROR: Nested mappings are not allowed in compact mappings at line 1, column 7

Swept across every generated frontmatter file in the tree:

BASE (origin/main): 246 frontmatter files, 24 UNPARSEABLE
   affected tool dirs: .codebuddy, .crush, .lingma, .qoder
PR:                 246 frontmatter files,  0 UNPARSEABLE

So every command file OpenSpec generates for CodeBuddy, Crush, Lingma, and Qoder has broken frontmatter today. That's a shipping bug affecting four integrations, and it's invisible unless you actually parse the output — which is presumably why it survived this long. Good find.

2. Does it work — yes, all 24 fixed, nothing else broken

0 unparseable files after. And critically, the fix doesn't just make things parse — it makes them parse to the right values.

3. Breaking changes — none semantically, and I verified this rather than assuming

This was my main concern, because the diff also removes quotes in places (several adapters previously always-quoted description, and now only quote when needed). So I parsed both trees and deep-compared the resulting objects for every file that was already valid:

previously-broken files (excluded):   24
semantically IDENTICAL frontmatter:  222
semantically CHANGED frontmatter:      0
files with body (non-frontmatter) differences: 0

Zero semantic changes across 222 files, and zero body changes. Every quoting difference is stylistic — description: Archive a completed change... and description: "Archive a completed change..." parse to the identical string. That also transitively proves the five deleted local escapeYamlValue copies (claude, qwen, trae, windsurf, zcode) were behaviourally equivalent to the shared one for real inputs, which is the thing I'd otherwise have had to take on faith.

pnpm generate:skills produces no drift, so the committed skills/*/SKILL.md golden files are unaffected.

The one thing to be aware of: on the next openspec update, users of the quote-style-affected tools will see cosmetic diffs in their generated command files (quotes added or removed). Nothing breaks, but it's churn worth a line in the changeset — and I'd note there's no changeset in this PR, which there probably should be given it's a user-visible fix.

4. Scope — correctly surgical, and net-negative complexity

escapeYamlValue already existed in src/core/command-generation/yaml.ts. This adds 14 lines to it (''"", plus formatTagsArray) and deletes ~80 lines of duplicated or missing escaping across adapters. No new architecture; it removes five divergent copies of a helper and routes everything through the existing one. That's exactly the right direction.

On the tests

Full suite on this branch: 112 files / 2275 tests, all passing, matching your reported numbers.

For transparency: my first run showed 3 failures, but they did not reproduce on a clean re-run (that run took 332s vs. 45s, so it was almost certainly resource contention on my machine from parallel worktree builds, not this PR). I'm calling it green.

The one modified existing expectation is correct and worth stating explicitly:

-      expect(output).toContain('description: \n');
+      expect(output).toContain('description: ""');

That's not a weakened test — a bare description: parses as null, not empty string, so the old output silently changed the value's type. "" round-trips properly. Good catch.

Nit (optional, no action needed)

escapeYamlValue's needsQuoting regex includes ,, which is only special in YAML flow context ([...], {...}), not in block mappings. That's why the explore description picks up quotes:

- description: Enter explore mode - think through ideas, investigate problems, clarify requirements
+ description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"

Harmless and still correct, but it's the source of most of the cosmetic churn above. Narrowing the regex would reduce the diff users see. That regex is pre-existing on main though, so tightening it is arguably its own change — I'd leave it, and just make sure the changeset mentions that generated files will shift.

Nice work — this is a well-targeted fix to a genuinely broken output path.

@hsusul
hsusul force-pushed the fix/command-adapter-yaml-escaping branch from 97cbe7b to 2b63f0f Compare July 27, 2026 19:40
@hsusul

hsusul commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Updated YAML frontmatter serialization so all string values are safely double-quoted and preserve their type across edge cases. Added round-trip coverage for all 24 YAML adapters, including dedicated Kiro and Lingma tests. All 960 command-generation tests pass.

@hsusul

hsusul commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Addressed review feedback in commit 8c632a8: aligned archive commands with /opsx:sync, made spec paths root-aware, and updated bulk archive to sync and verify each change before moving it. Regenerated committed skills and added regression coverage for template parity.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/core/command-generation/adapters.test.ts (1)

897-905: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale test rationale: "quoting is triggered by another special char" no longer applies.

This test's title/comment describes the old conditional-quoting behavior (backslash alone didn't trigger quoting, only : did). Since escapeYamlValue now always double-quotes and escapes every value unconditionally, the premise is outdated and may confuse future readers about why this assertion holds.

📝 Suggested comment update
-    it('should escape backslashes when quoting is triggered by another special char', () => {
-      // Backslash alone does not trigger quoting, but once quoting is on (via ':')
-      // every backslash must be doubled. Locks the replace(/\\/g, '\\\\') branch.
+    it('should escape backslashes in description', () => {
+      // Every value is now unconditionally double-quoted, so backslashes must
+      // always be doubled. Locks the replace(/\\/g, '\\\\') branch.
       const output = zcodeAdapter.formatFile({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/command-generation/adapters.test.ts` around lines 897 - 905, Update
the test title and inline comments in the backslash-escaping case within the
relevant adapter test to reflect that values are now always double-quoted and
backslashes are escaped unconditionally. Keep the existing input and assertion
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/core/command-generation/adapters.test.ts`:
- Around line 897-905: Update the test title and inline comments in the
backslash-escaping case within the relevant adapter test to reflect that values
are now always double-quoted and backslashes are escaped unconditionally. Keep
the existing input and assertion unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 14af19e8-fa20-4755-ba9e-7bed75fba36d

📥 Commits

Reviewing files that changed from the base of the PR and between c8fe7fe and 456f006.

📒 Files selected for processing (9)
  • src/core/command-generation/adapters/continue.ts
  • src/core/command-generation/adapters/cursor.ts
  • src/core/command-generation/adapters/iflow.ts
  • src/core/command-generation/yaml.ts
  • test/commands/artifact-workflow.test.ts
  • test/core/command-generation/adapters.test.ts
  • test/core/command-generation/generator.test.ts
  • test/core/command-generation/yaml.test.ts
  • test/core/init.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/core/command-generation/adapters/continue.ts
  • src/core/command-generation/adapters/iflow.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/openspec-bulk-archive-change/SKILL.md`:
- Around line 132-146: Honor recorded conflict-resolution exclusions by making
delta-spec sync and verification conditional: route excluded, unimplemented
deltas through an explicit “sync skipped” path and allow their archive without
requiring verification, while retaining sync and verification for included
deltas. Apply this in skills/openspec-bulk-archive-change/SKILL.md (132-146),
both corresponding sections of
src/core/templates/workflows/bulk-archive-change.ts (134-146 and 405-417), and
update test/core/templates/skill-templates-parity.test.ts (253-277) to assert
the rendered instructions preserve the skipped-sync path and omit verification
requirements for excluded deltas.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ac99c1df-b10d-4443-a904-b41d07d59e88

📥 Commits

Reviewing files that changed from the base of the PR and between 456f006 and 206522d.

📒 Files selected for processing (4)
  • skills/openspec-bulk-archive-change/SKILL.md
  • src/core/templates/workflows/archive-change.ts
  • src/core/templates/workflows/bulk-archive-change.ts
  • test/core/templates/skill-templates-parity.test.ts

Comment thread skills/openspec-bulk-archive-change/SKILL.md Outdated

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The YAML hold is resolved at 206522d: all 24 YAML adapters now round-trip the scalar edge cases as strings, root-aware inline sync/verification is covered, and build, lint, focused tests, full tests, and generated-skill parity are clean. One blocker remains in bulk archive: step 5 can exclude an unimplemented delta from sync, but step 8 still syncs and verifies every delta unconditionally; please carry the recorded inclusion/exclusion set into execution, sync and verify only included deltas, explicitly report excluded ones as “sync skipped,” and cover the mixed included/excluded path in both generated variants.

@hsusul

hsusul commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining bulk-archive blocker in b3b037b. Both generated variants now carry the per-delta inclusion/exclusion decisions into execution. Only included deltas are synced and verified; excluded deltas are reported explicitly as sync skipped. Added mixed included/excluded regression coverage for both generated variants.

Resolve conflicts between this branch's bulk-archive rework and main's
runtime-context/artifact-rules guidance (Fission-AI#1062) by unioning both sides:

- archive-change: keep main's specs-instruction fetch gate; keep this
  branch's /opsx:sync naming in the opsx command variant.
- bulk-archive: step 8a syncs only includedDeltas AND carries main's
  specs-rule snapshot + artifact-rule scoping bullets; guidelines lists
  keep both sides' bullets.
- Regenerated skills/ and parity hashes rather than hand-merging them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator

Resolved the conflicts with main in 62c7eab (pushed to the fork branch — thanks for leaving maintainer-edit on).

What conflicted: only the archive/bulk-archive templates and their parity hashes. main had landed #1062 (fetch openspec instructions specs --json before any main-spec write; artifact rules scoped to written specs), which rewrote the same blocks this branch reworked for per-delta include/exclude.

How it was resolved — union, nothing dropped from either side:

  • archive-change.ts: kept main's specs-instruction fetch gate; kept this branch's /opsx:sync naming in the opsx command variant (the skill variant still says openspec-sync-specs, matching the parity assertion).
  • bulk-archive-change.ts: step 8a now syncs only includedDeltas and carries main's specs-rule-snapshot and artifact-rule-scoping bullets. Both guidelines lists carry both sides' bullets.
  • skills/*/SKILL.md and the parity hash tables were regenerated (pnpm build && pnpm generate:skills && pnpm regen:parity-hashes), not hand-merged.

Verified: no it() block lost from either side, pnpm-lock.yaml untouched, build + lint + tsc --noEmit clean, full suite 3095/3095 passing locally, and CI is now green on the merge commit (Lint & Type Check, Test on linux/macOS/Windows, Audit, Dependency Review).

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 62c7eab. The YAML serializers round-trip edge-case strings across all adapters, and bulk archive now carries per-delta included/excluded decisions through inline sync, verification, and sync-skipped reporting in both generated variants while preserving current runtime guidance. 1,094 focused tests, build, lint, generated-skill parity, the cross-platform suite, audit, dependency review, and release tracking all pass.

… close test gaps

Adversarial review of the merged branch turned up four gaps:

- Bulk archive tells the sync workflow to ignore `excludedDeltas`, but
  main's sync-specs calls `existingOutputPaths` the "complete list" of
  delta specs. An agent following both would sync the delta the caller
  withheld, step 8b would not catch it (it verifies only included
  deltas), and the run would still report `sync skipped`. Sync now
  honors a caller-supplied subset, mirroring the inline rule-snapshot
  handoff main already added.
- escapeYamlValue left C0/DEL/C1 control characters raw. The repo's own
  parser accepts them, so tests passed while stricter parsers used by
  other tools reject the document. Emit them as \xHH.
- The adapter matrix was a hand-maintained list driving only
  `description`, so raw interpolation in lingma's name/category/tags —
  and any newly registered adapter — passed green. It now derives from
  the registry and drives every string field.
- Four bulk-archive template lines were guarded only by golden hashes,
  which this repo regenerates as routine.

Also corrects the escapeYamlValue docstring, which still described the
pre-PR conditional-quoting behavior, and adds the missing changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator

Ran four adversarial review passes over the merged branch (YAML correctness, template integrity, mutation testing, downstream blast radius). Fixes pushed in 62bb846.

One real functional defect, introduced by the merge. Step 8a tells the sync workflow to sync only includedDeltas and ignore the rest — but main had independently hardened sync-specs.ts to call artifactPaths.specs.existingOutputPaths the "complete list of delta spec files" (permissive at the merge base, which is what this branch was written against). An agent following both would sync the delta bulk archive deliberately withheld; step 8b wouldn't catch it, because it verifies only included deltas; and the run would still report sync skipped. Sync now honors a caller-supplied subset, mirroring the inline rule-snapshot handoff main already added for the same caller.

Escaping hardened one class further. escapeYamlValue left C0/DEL/C1 control characters raw. The yaml package this repo tests with accepts them; js-yaml and PyYAML reject the document outright — so the suite would stay green while the command failed to load in other tools. They now emit as \xHH.

Two test gaps that mattered:

  • The adapter matrix was a hand-maintained list driving only description. Raw interpolation in lingma's name, category, or tags survived, and a newly registered adapter with raw interpolation shipped 960/960 green. It now derives from CommandAdapterRegistry.getAll() minus an explicit non-YAML set, and drives every string field. Verified by re-running each mutation: all now fail 27 tests.
  • Four bulk-archive template lines were guarded only by the golden hash, which this repo regenerates as routine. They have assertions now.

Also corrected the escapeYamlValue docstring (it still described the pre-PR conditional-quoting behavior) and added the missing changeset — this PR carries three user-visible changes and had no release tracking.

Left alone deliberately, as pre-existing and outside this PR:

  • src/core/shared/skill-generation.ts:139 builds SKILL.md frontmatter by raw interpolation — the same bug class, on the skills surface. Worth a follow-up.
  • adapters/gemini.ts:23 interpolates a description raw into TOML.
  • The bulk-archive skill variant is missing a blank line before step 4, so CommonMark lazy-continues the heading into the previous bullet. Present on main at the same line.

Full suite 3113 passing, lint and tsc --noEmit clean, generated skills and hashes regenerated.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 62bb846. The latest fixes correctly preserve caller-supplied delta subsets in both sync variants, escape C0/DEL/C1 control characters for strict YAML parsers, and derive adapter/string-field coverage from the registry. Build, lint, 1,112 focused tests, strict spec validation, generated-skill parity, cross-platform CI, audit, dependency review, and release tracking all pass.

@clay-good
clay-good self-requested a review July 28, 2026 02:22
A second adversarial pass found the previous fix incomplete. Narrowing
step 3 ("Find delta specs") left step 4 — the loop that actually applies
the changes — still reading "for each capability delta spec path
returned by the CLI". An agent treating step 3 as descriptive and step 4
as operative re-widens to the full list and syncs the delta bulk archive
withheld: the original defect, one step further down the template.

Step 4 now iterates the step-3 selection, and the parity test pins both
the new wording and the absence of the old.

Also from that pass:
- Generalize the carve-out beyond archive. It was conditioned on
  "archive invoked this workflow inline", so a user asking /opsx:sync to
  sync one delta read as an instruction to ignore them.
- Define the two undefined edges: a named path outside
  existingOutputPaths, and an empty named list. Both stop and report
  rather than proceeding on a guess.
- Drive control characters through the adapter matrix. It drove none, so
  the escaping this suite exists to prove had no adapter-level coverage
  and the raw-CR assertion could never fail. Verified live by mutation.
- Give contentDerivedFields two markers that differ in length and shape.
  Same-shaped markers render identically for a length- or slice-derived
  field, which would drop it from every assertion silently.

Drops the `not.toContain('complete list of delta spec files')`
assertion: it banned one exact synonym while any reword of the same
conflicting instruction passed, so it read as coverage without being it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator

Second adversarial round, on the previous round's own fixes rather than the original diff. Pushed as 5bfd574.

The earlier fix was incomplete, in the same way and for the same reason. Narrowing step 3 ("Find delta specs") and the guardrails left step 4 — the loop that actually applies the changes — still reading "For each capability delta spec path returned by the CLI." An agent that reads step 3 as descriptive and step 4 as operative re-widens to the full list and syncs the delta bulk archive withheld. Same wrong outcome as the original defect, one step further down the template. Step 4 now iterates the step-3 selection, and the parity test pins both the new wording and the absence of the old, verified by mutation.

Two more from that pass:

  • The carve-out was conditioned on archive invoking sync, so a user asking /opsx:sync to sync only one delta read as an instruction to ignore them. Generalized to any caller.
  • A named path outside existingOutputPaths, and an empty named list, were both undefined. Both now stop and report rather than proceeding on a guess.

The control-character escaping was independently validated against js-yaml, PyYAML and the repo's yaml. Worth recording what the probes showed, because the bug is worse than "strict parsers reject it": for 0x7f/0x85/0x9f the character is silently dropped by js-yaml and the repo's own parser, and PyYAML turns NEL into a space. \x85 is the only form that round-trips in all three. Escaping every code point 0x00–0xFF now round-trips with 0 mismatches across all three parsers, and output is byte-identical for all 12 real command templates across all 28 adapters — the change is purely defensive.

Test hardening, both found by mutating the previous round's tests:

  • The matrix drove no control character at all, so the escaping this suite exists to prove had zero adapter-level coverage and the raw-CR assertion was dead code. Six control-char cases added; confirmed the assertion now fires.
  • contentDerivedFields used two same-shaped markers, which would render identically for a length- or slice-derived field and drop it from every assertion silently. Markers now differ in length and shape.

Exhaustive mutation of the rebuilt matrix — every adapter × every content-derived field × all cases — found 0 survivors, plus 14 in-tree mutations all killed.

I also dropped the not.toContain('complete list of delta spec files') assertion I added last round. It banned one exact synonym while "the authoritative list" would have sailed through reintroducing the same conflicting instruction — coverage-shaped, but not coverage.

Non-blocking, not fixed here: sync's success output has no place to list withheld paths, so a caller can't cross-check what was skipped; and gemini.ts interpolates a description raw into TOML (pre-existing, correctly outside the YAML adapter set).

3257 tests passing, lint and tsc --noEmit clean, CI green.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5bfd574. The sync workflow now carries the step-3 delta selection into the operative step-4 loop, stops on out-of-scope or empty named subsets, and supports caller narrowing beyond archive without widening back to the CLI list. The adapter matrix now exercises control characters and uses shape/length-distinct markers. Build, lint, 1,256 focused tests, strict spec validation, generated-skill parity, cross-platform CI, audit, dependency review, and release tracking all pass.

@clay-good
clay-good added this pull request to the merge queue Jul 28, 2026
Merged via the queue into Fission-AI:main with commit fb19699 Jul 28, 2026
13 checks passed
clay-good added a commit to mehdishahdoost/OpenSpec that referenced this pull request Jul 28, 2026
…t skills

Addresses the two release blockers on this PR.

Archive: the change's MODIFIED blocks were written against an older
canonical `cli-init`, so `openspec archive add-devin-desktop-support`
aborted rather than merging. The deltas are regenerated from the current
canonical specs (cli-init `Skill Generation` + `Slash Command
Generation`, cli-update `Slash Command Updates`, and a new
`ai-tool-paths` delta for the `.devin` skillsDir), each restating every
existing scenario so archive is purely additive.

Invocation syntax: only Devin Desktop reads `.devin/workflows/`, so a
`/opsx-*` workflow reference is dead text on Devin Local, which supports
skills only. Devin now takes the skill-reference transformer, so skill
bodies and the getting-started hint say `/openspec-*`. Workflow bodies
keep hyphen references, applied by devinAdapter itself.

The adapter also drops its private copy of escapeYamlValue /
formatTagsArray in favor of the shared helpers main centralized in
Fission-AI#1447, which quote unconditionally and escape control characters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to edisplay/OpenSpec that referenced this pull request Jul 28, 2026
…AI#1167)

* proposal: add devin desktop support

* feat(adapters): add devin desktop command adapter

- Create new Devin Desktop adapter for .devin/workflows/opsx-<id>.md
- Register adapter in CommandAdapterRegistry
- Export adapter from adapters index
- Update docs/supported-tools.md with Devin Desktop entry
- Add 'devin' to available tool IDs list

Devin Desktop uses the same Cascade workflow system as Windsurf,
making it a natural migration path for existing users.

* fix(config): add devin desktop to AI_TOOLS

Add Devin Desktop entry to AI_TOOLS configuration so that:
- getToolsWithSkillsDir() includes 'devin' as a valid tool ID
- getWorkspaceSkillToolIds() returns 'devin' in the list
- parseWorkspaceSkillToolsValue() accepts 'devin' as valid input
- openspec init --tools devin works correctly

This fixes validation failures where 'devin' was documented in
docs/supported-tools.md but not recognized by validation functions
that derive valid IDs from AI_TOOLS.

* fix(devin-adapter): escape implicit YAML scalars in frontmatter

Update escapeYamlValue to detect and quote implicit YAML scalars that
would be coerced by parsers:
- Booleans: true, false, yes, no, on, off
- Null variants: null, ~
- Numbers: integers, floats, exponentials, hex (0x), octal (0o)
- Edge cases: standalone dash (-) and dot (.)

This ensures values like 'true', '123', 'null' remain strings in YAML
frontmatter instead of being interpreted as booleans, numbers, or nulls.

Preserves existing escaping logic for special characters and newlines.

* test(devin-adapter): add comprehensive tests for Devin Desktop adapter

Add test coverage for the Devin Desktop adapter including:
- Command reference transformation from colon to hyphen syntax
- YAML frontmatter escaping for special characters and implicit scalars
- File path generation for workflows
- Integration with available tools detection
- Init and update command workflows

* Add cross-platform testcase.

* fix(devin): refresh deltas against canonical specs and point skills at skills

Addresses the two release blockers on this PR.

Archive: the change's MODIFIED blocks were written against an older
canonical `cli-init`, so `openspec archive add-devin-desktop-support`
aborted rather than merging. The deltas are regenerated from the current
canonical specs (cli-init `Skill Generation` + `Slash Command
Generation`, cli-update `Slash Command Updates`, and a new
`ai-tool-paths` delta for the `.devin` skillsDir), each restating every
existing scenario so archive is purely additive.

Invocation syntax: only Devin Desktop reads `.devin/workflows/`, so a
`/opsx-*` workflow reference is dead text on Devin Local, which supports
skills only. Devin now takes the skill-reference transformer, so skill
bodies and the getting-started hint say `/openspec-*`. Workflow bodies
keep hyphen references, applied by devinAdapter itself.

The adapter also drops its private copy of escapeYamlValue /
formatTagsArray in favor of the shared helpers main centralized in
Fission-AI#1447, which quote unconditionally and escape control characters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): correct commands-only hint, fill doc gaps, cover both surfaces

Follow-up from adversarial review of the previous commit.

The devin special case in getTransformerForTool was unconditional, so
under commands-only delivery — where `.devin/skills/` is deleted — the
getting-started hint named `/openspec-propose`, a skill that is not on
disk. Devin now takes the skill transformer only when skills are
generated, and the hyphen form otherwise. The cli-init delta records the
fallback, and a unit test pins all three delivery modes.

Docs: `devin` was missing from the `--tools` list in docs/cli.md (which
mirrors the list supported-tools.md already had) and from the
command-syntax tables in docs/commands.md and docs/how-commands-work.md.
The supported-tools row gains a footnote citing Cognition's docs for the
`.windsurf/` -> `.devin/` move and the Devin Local workflow gap.

Tests: init and update now assert both surfaces — workflows carry
`/opsx-*`, skills carry `/openspec-*`, neither carries `/opsx:` — and
update checks the seeded skill was actually refreshed. Adds the negative
detection case. Drops three devin-only YAML assertions that duplicated,
less rigorously, the registry-derived escaping matrix that now enrolls
devin automatically.

Also reverts an unrelated zcode export and lingma reorder that a merge
resolution had pulled into adapters/index.ts. zcodeAdapter is registered
but missing from that barrel on main; that is a pre-existing gap and
belongs in its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): name the right command in the profile migration notice

The profile-migration notice printed by both `init` and `update` hardcoded
`/opsx:propose` for every adapter-backed tool. Devin registers no such
command on any surface — its workflows answer to `/opsx-propose` and its
skills to `/openspec-propose` — so an upgrading Devin user was told to run
something that does not exist:

  Migrated: custom profile with 6 workflows
  New in this version: /opsx:propose.

The reference now goes through getTransformerForTool, the same call
init.ts already makes for the getting-started hint. Devin prints
`/openspec-propose`; opencode and the other filename-invoked tools are
corrected to `/opsx-propose` as a side effect; claude is unchanged.

Also corrects two inherited false claims in the cli-update delta — Devin
workflows carry no OpenSpec markers, and update writes every profile
workflow rather than only refreshing files that already exist, which the
PR's own test demonstrates. Qualifies the supported-tools footnote for
commands-only delivery, and strips trailing whitespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): keep the cli-update delta in step with the canonical spec

The delta restates the whole 'Slash Command Updates' requirement, and its
copy of the OpenCode scenario predated Fission-AI#1471 — archiving it would have
quietly reverted the spec to calling the hyphen rewrite an OpenCode special
case, the hand-maintained framing Fission-AI#1471 removed. Archive on a scratch copy
is now purely additive.

Also point tasks.md at the generator rather than the deleted
transformToHyphenCommands, and enroll devin in the pure-formatter tripwire —
it is the one adapter whose private body transform was just removed, so it
is the likeliest to have it re-added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(adapters): follow the Windsurf rename to Devin Desktop, with migration

Windsurf was rebranded to Devin Desktop on 2026-06-02 and its config
directory moved: `.devin/` is the preferred read+write location, `.windsurf/`
a legacy read-only fallback. Devin Local does not read `.windsurf/` at all,
so an existing Windsurf user's OpenSpec files are invisible to it.

Carrying `devin` as a second tool id alongside `windsurf` would list one
product twice and leave upgraders with two parallel installs — `openspec
update` even told them to create the second one ("Detected new tool: Devin
Desktop"). This follows the rename instead, as the repo already did for
Kimi CLI -> Kimi Code:

- `windsurf` is retired as a tool id; `devin` takes its place, with
  `detectionPaths: ['.devin', '.windsurf']` so pre-rebrand projects are
  still recognized. The Windsurf adapter is replaced, not duplicated.
- `TOOL_ID_ALIASES` keeps `--tools windsurf` resolving, so existing setup
  scripts and CI keep working; they now configure `.devin/`.
- OpenSpec-managed skills (`openspec-*`) and command files (`opsx-*`) under
  `.windsurf/` move to `.devin/`. The kimi migration handled skills only;
  command files now move too, deriving the legacy path from the adapter's
  own getFilePath rather than hard-coding a layout.
- The move is offered, not taken: nothing on disk distinguishes a user who
  took the rebrand from one still on a pre-rebrand Windsurf build that reads
  only `.windsurf/`. `openspec update` explains the rename and asks; --force
  and non-interactive runs migrate; declining leaves every file untouched and
  says what that costs. Files the user wrote are never moved.

Also gives Devin its own row in the authoritative invocation table — the
catch-all row claimed `/opsx-<id>` for both agents, which is wrong for Devin
Local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): stop the migration from deleting anything it does not own

An adversarial pass found two ways the move destroyed files.

Symlinked roots wiped the install. `ln -s .devin .windsurf` is a realistic
way to straddle the rebrand, and it makes source and destination the same
file — so the "destination exists, drop the legacy copy" branch deleted the
only copy. Twelve generated files, gone, and not regenerated: the wipe
happens before tool detection, so update then reported no configured tools.
Both roots are now realpath'd and a self-move is skipped.

User content inside an OpenSpec-managed path was deleted. The same branch
rm -rf'd the whole legacy skill directory, taking a hand-written
reference.md beside SKILL.md with it, and deleted a legacy command file even
when the user had edited it. Now only SKILL.md is removed from a skill
directory, and a command file is removed only when byte-identical to the
one that survives — an edit is left where it is.

Also: declining the move stranded the user. `update` then printed "No
configured tools found. Run openspec init", which is wrong — the project is
configured, just in the directory OpenSpec no longer writes. It now says so
and how to resume. A closed stdin during the prompt aborted the whole
update; it is treated as a decline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(devin): add a changeset for the Windsurf rename and migration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): move only SKILL.md, never the skill directory around it

alfred caught a data-loss path the earlier fix missed. When the destination
did not yet exist, migration renamed the whole legacy skill directory into
`.devin/` — carrying any file the user kept beside `SKILL.md` with it. That
destination is a directory OpenSpec owns and removes on its own: under
commands-only delivery, or for a workflow outside the active profile. So the
move handed the user's file to a later rm and it vanished.

Reproduced on `d94af8b`: with `delivery: commands`, a `reference.md` beside a
legacy `SKILL.md` was gone after `openspec update`.

Only `SKILL.md` crosses now, in both branches; anything else stays under the
legacy root, and the legacy directory is still removed when the move leaves
it empty. Regression tests cover the commands-only and deselected-workflow
cases and both fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): treat an edited skill the way an edited command is already treated

A final adversarial pass found the two paths disagreeing. When both roots
held the same file with different content, the command path compared bytes
and kept the user's version; the skill path deleted it with no comparison —
so one `openspec update` destroyed an edited SKILL.md while preserving an
edited opsx-*.md in the same project.

Both now share one `classifyManagedFile` rule: move when the destination is
empty, drop the legacy copy only when byte-identical, otherwise leave it.
Anything left behind is reported, so a user who customized a file knows two
copies exist rather than discovering it later.

Note on the other finding from that pass: OpenSpec regenerating or pruning
the files it owns is long-standing behavior, not something this PR
introduces. Verified against main — an edited SKILL.md under a deselected
workflow, and an edited selected skill and command, are all destroyed by
`openspec update` on 9a937cb too. No regression, so left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devin): report divergent legacy files even when nothing is movable

collectLegacyToolMigrations only returned a result when something moved, so
a project where EVERY legacy file differs from its counterpart produced no
output at all — two divergent copies and not a word about them. That is the
one case where the report matters most, since it is entirely made of files
the migration deliberately refused to touch.

Kept-only results are retained now. Callers gate on hasMovableContent(), so
a kept-only result reports what was left without offering to move nothing
and without claiming a migration that did not happen.

Also reworded the notice. A legacy file can differ because the user edited
it or simply because an older OpenSpec generated it, so it no longer asserts
an edit — it states that nothing was overwritten and leaves the user to
compare the two copies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(devin): stop matching the unrelated profile-migration line

The kept-only regression asserted no line matched /Migrated\s*:/, which also
matches OpenSpec's profile migration message, "Migrated: custom profile with
N workflows". That line only prints when the global config has no profile
yet — true on a fresh CI runner, false on a developer machine that has run
OpenSpec before — so the test passed locally and failed on all three CI
platforms.

Now matched on the directory arrow, ".windsurf → .devin", which is specific
to a migration report and unaffected by config state.

Reproduced both ways with an empty XDG_CONFIG_HOME: the old assertion fails
there, the new one passes, and the full suite is green under CI's
XDG_CONFIG_HOME + VITEST_MAX_WORKERS=4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
clay-good added a commit that referenced this pull request Jul 28, 2026
The gemini adapter interpolated the description into a TOML basic string
and the body into a multiline basic string with no escaping. Every
current template value happens to be safe; the first description with a
double quote or backslash would silently produce invalid TOML for all
Gemini command files. Escape both contexts (#1447 fixed the same class
for the YAML adapters but scoped itself to YAML).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
birhantprkc pushed a commit to birhantprkc/OpenSpec that referenced this pull request Jul 29, 2026
…-audit follow-ups (Fission-AI#1475)

* fix(archive): make the scenario-drift check fence-aware

parseScenarioBlocks matched #### Scenario: headers on raw lines while the
validator's countScenarios masks fenced code blocks (Fission-AI#1151). The drift
check (Fission-AI#1391) inherited the raw scan, so a fenced scenario example in the
current spec aborted an archive that validate had passed, and a fenced
name in the MODIFIED block counted as keeping a scenario the block had
actually dropped. Build the shared code-fence mask and skip masked lines
in both the header scan and the block-end scan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): tear down the redirected request when the budget expires

The overall request budget was armed inside the first send() and its
callback closed over that hop's request. After a redirect the timer
destroyed the already-dead first request, so a redirect target that
trickled bytes kept resetting its idle timeout and held the socket open
until the body-size cap. Track the in-flight request and have the budget
timer destroy whichever one is open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(release): add changesets for user-facing changes missing from the 1.7.0 notes

18 feat/fix commits merged since v1.6.0 without a changeset, so the
pending Version Packages PR would have released them silently: five tool
integrations (ZCode, Hermes, CodeArts, Kimi Code rename, Codex
skills-only), skills.sh distribution, symlinked schema dirs, nested spec
discovery, drift multiplicity, checkbox markers, Windows welcome input,
npx avoidance, doctor store drift, local dates, missing-core-workflows
warning, store-aware main specs, open-questions guidance, and spec
content guidance. Plus changesets for this branch's two fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(adapters): escape TOML-active characters in Gemini command files

The gemini adapter interpolated the description into a TOML basic string
and the body into a multiline basic string with no escaping. Every
current template value happens to be safe; the first description with a
double quote or backslash would silently produce invalid TOML for all
Gemini command files. Escape both contexts (Fission-AI#1447 fixed the same class
for the YAML adapters but scoped itself to YAML).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): harden install detection and redirect handling

Three follow-ups from the release audit:
- A path segment literally named volta (a user or project directory)
  classified the install as volta-managed and swallowed the upgrade
  offer. The undotted spelling now requires volta's own tools/image
  layout, matching how pnpm and yarn already demand corroboration.
- The Windows npm-ownership fallback checked that the npm prefix exists,
  which is true of any X\node_modules\pkg tree, hand-copied ones
  included. Corroborate with the openspec.cmd shim npm actually writes.
- A https registry redirecting to plain http was followed; a MITM on
  that reply controls the newer-version answer. Refuse the downgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(cli): export zcodeAdapter from the barrel and sync a completion description

zcode was registered but missing from the adapters barrel (its test
imported the module directly), and the completion registry still carried
the pre-Fission-AI#1062 description for the instructions command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(parser): strip a UTF-8 BOM before parsing specs and deltas

A BOM-prefixed delta spec (Windows editors, PowerShell Out-File) failed
validate and archive with 'No delta sections found' because the first
line never matched '## ADDED Requirements'. Strip the BOM in both
normalizers, the same way tool detection already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): reject over-long change names with a validation message

A 300-character change name surfaced two raw ENAMETOOLONG errno dumps
from stat and mkdir. Bound the name at 200 characters in
validateChangeName so the failure is a normal validation error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(archive): finish the early-sync no-op rules for MODIFIED and RENAMED

Two asymmetries left over from the Fission-AI#1376/Fission-AI#1386/Fission-AI#1437 no-op work:

- MODIFIED counted every delta as applied even when the block was
  byte-equal to the main spec, so a fully early-synced change rewrote
  the file (normalization churn), printed '~ N modified', and reported
  specsUpdated: true where its ADDED/REMOVED/RENAMED twins print 'Specs
  already in sync; no files changed.' Count only real replacements.
- RENAMED's already-synced skip (source gone, target present) had no
  near-miss guard: a case/whitespace variant of the source still in the
  spec means a typo'd header, and REMOVED already hard-aborts on that
  signal. Apply the same guard, excluding the target itself so a
  case-only rename still no-ops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(validate): stop reporting an unreadable specs dir as 'no deltas'

The delta-validation loop swallowed every error as 'if no specs dir,
treat as no deltas', so an EACCES capability folder produced the
misleading 'Change must have at least one delta' while archive let the
same error propagate. Tolerate only ENOENT and ENOTDIR (a stray specs
file); anything else stays loud, matching discoverSpecFiles' documented
fail-loud contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): say when commands-only delivery leaves a tool with nothing

Under delivery: commands, update removed the skills of adapterless
skills-only tools (Hermes, Kimi Code, Vibe, CodeArts, ForgeCode) without
a word — leaving zero OpenSpec artifacts while the tool's detection dir
kept re-suggesting an init that would also generate nothing. Print the
same per-tool configuration correction init already prints, pointing at
'openspec config set delivery both'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(completion): honor $ZSH and $ZSH_CUSTOM for Oh My Zsh installs

The installer used a set $ZSH only as an is-installed signal and then
wrote to ~/.oh-my-zsh regardless, so a custom OMZ location got a
freshly created ~/.oh-my-zsh tree that no shell ever loads — and
isInstalled/uninstall looked in the same wrong place. Route every path
through the $ZSH/$ZSH_CUSTOM-aware helpers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(init): make the static welcome screen wait for the Enter it asks for

The static branch printed 'Press Enter to select tools...' and returned
immediately, so the Enter landed in the tool picker and submitted the
pre-selected set sight-unseen. Fission-AI#1462 routed reduced-motion,
OPENSPEC_NO_ANIMATION, --no-animation, NO_COLOR, and narrow-terminal
users onto this path. Wait in a TTY; drop the prompt line when there is
no TTY to wait on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(feedback): keep the manual fallback on every gh failure

Only missing-gh and unauthenticated flows showed the formatted feedback
and pre-filled submission URL; issues-disabled, network, or rate-limit
failures printed gh's stderr and discarded the path to submit what the
user had already typed. Route those through the same manual fallback,
preserving gh's exit code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(update): model the npm shim in the Windows prefix fixture

The ownership corroboration now checks for the openspec.cmd shim npm
writes beside node_modules; the Homebrew-prefix fixture built the layout
without it, so the test failed on windows-pwsh. Write the shim in the
fixture and pin the inverse: the same shape with nothing npm wrote (a
hand-copied portable tree) is not an npm install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): require volta's full tools/image layout for the undotted spelling

The corroboration used has('tools', 'image'), which is some() — volta
AND (tools OR image) — so /srv/volta/tools/apps/... still classified as
a Volta install and swallowed the upgrade offer. Require both segments,
matching the real %LOCALAPPDATA%\Volta\tools\image layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(adapters): escape control characters in Gemini multiline prompts

escapeTomlMultilineBasicString handled backslashes and quote-triples but
not the C0 controls that are as invalid in a multiline basic string as
in a single-line one. Reuse TOML_CONTROL_CHARS, applied last so the
escapes it introduces are not re-doubled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(completion): finish the $ZSH_CUSTOM support and isolate it in tests

The fpath verification advice still grepped the literal
custom/completions, which a relocated $ZSH_CUSTOM need never contain —
grep the actual directory instead. The installer tests cleared only
$ZSH, so on a machine exporting $ZSH_CUSTOM they would have written
into (and deleted from) the developer's real OMZ custom dir — the same
leakage class Fission-AI#1400 fixed for $ZSH. Clear/restore both, and pin the
custom-location paths with two new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(release): correct the hermes and zcode changeset wording

Hermes is skills-only (no command adapter), and zcode's namespaced
commands register /opsx:<id>, not /opsx-* — the release notes must not
reintroduce the invocation-spelling confusion Fission-AI#1471 removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(feedback): pin the manual fallback on a non-label gh failure

The new reportGhFailure output (formatted feedback + pre-filled URL) had
no coverage; the network-failure test now asserts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(completion): match fpath entries as literal strings in the OMZ guidance

The verification advice interpolated the completions dir into
grep "<dir>" where regex metacharacters make the check unreliable and
quotes could break the displayed command. Print one fpath entry per
line and match with grep -F on a shell-quoted literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(adapters): never emit a bare carriage return in Gemini TOML prompts

A lone CR is illegal in a multiline basic string — Python 3.13 tomllib
rejects the file — and the control-char pass deliberately skipped it on
the assumption it only appears as CRLF. Normalize CRLF to LF and escape
any remaining CR as \r. The escaping guarantee is now parser-backed:
smol-toml (new devDependency) round-trips every hostile body in the
regression matrix (lone CR, CRLF, CR before a quote run, NUL/VT/FF,
trailing backslash, four- and five-quote runs), and the same outputs
were verified against Python tomllib.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(nix): update the pnpm deps hash for the smol-toml devDependency

The lockfile changed, so the fixed-output derivation hash moved; value
taken from the CI mismatch report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants